[Flutter] サードパーティパッケージを使わずに状態を管理する
はじめに
「状態管理」、Flutterに限らずアプリケーション開発において考えなくてはならないトピックです。
Flutterのアプリ開発ではRiverpodなどの状態管理パッケージを使うことが多いと思いますが、公式ドキュメント、First week experience of Flutter の State management にFlutterが標準で提供している状態管理について書かれていました。
理解を深めたいと思い、読んだり自分でもコードを書いたりしてみました。
サンプルコードはお馴染みの「ボタンをタップすると値がカウントアップする」アプリです。
StatefulWidgetを使う
状態を管理する一番シンプルな方法です。
以下が実現できています。
- 状態のカプセル化
MyCounter
を使うウィジェットからはMyCounter
で管理しているStateは見えず、変更もできない。
- ライフサイクル
_MyCounterState
オブジェクトはMyCounter
ウィジェットが初めて構築された時に生成され、画面から取り除かれるまで存在する。
非常にシンプルですね。
class MyCounter extends StatefulWidget {
const MyCounter({super.key});
State<MyCounter> createState() => _MyCounterState();
}
class _MyCounterState extends State<MyCounter> {
int count = 0;
Widget build(BuildContext context) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text('Count: $count'),
TextButton(
onPressed: () {
setState(() {
count++;
});
},
child: const Text('Increment'),
)
],
),
);
}
}
ウィジェット間でStateを共有する
複数のウィジェット間でStateを共有したい場合、複数のアプローチがあります。
コンストラクタ引数でStateを渡す
まず、真っ先に思いつくのはStateをそれを必要としているウィジェットに渡すことです。
コンストラクタ引数でStateを受け取るようにして内部で保持すればbuild
メソッドの中でも使えます。
class MyCounter extends StatelessWidget {
final int count;
const MyCounter({super.key, required this.count});
Widget build(BuildContext context) {
return Text('$count');
}
}
ウィジェットを使う側もコンストラクタ引数にStateを渡すだけなのでわかりやすいです。
Column(
children: [
MyCounter(
count: count,
),
MyCounter(
count: count,
),
TextButton(
child: Text('Increment'),
onPressed: () {
setState(() {
count++;
});
},
)
],
)
でもこれって、Reactでいうpropsで情報を渡してるだけだなと思っていたら
sometimes called "prop drilling" in other frameworks
との記載がありました。やはりそのようです。
いわゆるバケツリレーなので、ウィジェットの階層が深くなるとReactのコンポーネントでも起きたのと同じような以下の問題が起きることになります。
- Stateを使うウィジェットに届けるまでに同じようなコードを書かないといけない
- Stateを使うウィジェットだけでなく中間のウィジェットもムダに
build
メソッドが呼ばれる
コードの保守性の観点からもパフォーマンスの観点からも良いとは言えないです。
そこでFlutterはInheritedWidget
というウィジェットを提供しています。
InheritedWidgetを使う
InheritedWidget
を使うと、ウィジェットの上位階層で保持しているStateを
階層を超えてStateを利用したいウィジェットに通知することができます。
InheritedWidget
を継承したクラスを作り、Stateを保持します。
staticなof
メソッドを定義し、BuildContext
のdependOnInheritedWidgetOfExactType
メソッドを使い、その結果を返すようにしておきます。
dependOnInheritedWidgetOfExactType
メソッドは引数のcontextから見てツリー上の祖先で直近のInheritedWidget
を検索・取得するAPIです。
また、Stateが変化した時に購読者(of
メソッドを呼んだウィジェット)に通知できるようにupdateShouldNotify
メソッドをオーバーライドします。古いStateと今のStateが異なっていたらtrue
を返すことで購読者に通知されます。
class MyState extends InheritedWidget {
const MyState({
super.key,
required this.count,
required super.child,
});
final int count;
static MyState of(BuildContext context) {
// This method looks for the nearest `MyState` widget ancestor.
final result = context.dependOnInheritedWidgetOfExactType<MyState>();
assert(result != null, 'No MyState found in context');
return result!;
}
// This method should return true if the old widget's data is different
// from this widget's data. If true, any widgets that depend on this widget
// by calling `of()` will be re-built.
bool updateShouldNotify(MyState oldWidget) => count != oldWidget.count;
}
InheritedWidget
を継承したMyState
を使う側は以下のようになります。
MyState
コンストラクタでStateと子ウィジェットを渡します。
渡しているのはChild1
ですが、Stateを使っているのはChild2
です。
Child2
のbuild
メソッド中のMyState.of(context).count;
でStateを取得しています。
Stateが変わるたびにChild2
のbuild
メソッドが呼び出されUIが更新されます。
Stateを使うChild2
ではStateを保持していません。
また、Child2
の親のChild1
にはStateに関するコードはありません。
バケツリレーが無くなりました 😄
class MyCounter extends StatefulWidget {
const MyCounter({super.key});
State<MyCounter> createState() => _MyCounterState();
}
class _MyCounterState extends State<MyCounter> {
int count = 0;
Widget build(BuildContext context) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
MyState(count: count, child: const Child1()),
TextButton(
onPressed: () {
setState(() {
count++;
});
},
child: const Text('Increment'),
)
],
),
);
}
}
class Child1 extends StatelessWidget {
const Child1({super.key});
Widget build(BuildContext context) => const Child2();
}
class Child2 extends StatelessWidget {
const Child2({super.key});
Widget build(BuildContext context) {
final count = MyState.of(context).count;
return Text('Count: $count');
}
}
コールバックで親ウィジェットにStateを渡す
これまで親から子にStateを共有する方法を見てきました。
子がStateを管理していて、親に通知したい場合にはコールバックを使います。
ValueChanged
型の定義は
typedef ValueChanged<T> = void Function(T value);
となっていて、指定した型の値を1つ受け取れる関数型です。
class Parent extends StatelessWidget {
const Parent({super.key});
Widget build(BuildContext context) {
return MyCounter(
// コールバック経由で値を受け取る
onChanged: (newCount) => {
debugPrint('New count: $newCount'),
});
}
}
class MyCounter extends StatefulWidget {
const MyCounter({super.key, required this.onChanged});
final ValueChanged<int> onChanged;
State<MyCounter> createState() => _MyCounterState();
}
class _MyCounterState extends State<MyCounter> {
int count = 0;
Widget build(BuildContext context) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text('Count: $count'),
TextButton(
onPressed: () {
final newCount = count + 1;
setState(() {
count = newCount;
});
// コールバック関数を呼ぶ
widget.onChanged(newCount);
},
child: const Text('Increment'),
)
],
),
);
}
}
状態管理をWidgetから切り離す
ここまでStateをウィジェット間で共有する方法を見てきましたが、あくまでStateの管理はStatefulWidget内で行ってきました。
同じStateを複数のウィジェットで共有するなら特定のウィジェットではなく、それ用のオブジェクトに状態管理を任せた方が良さそうです。Stateを更新するロジックもそのオブジェクトに実装できるとウィジェットからState更新のロジックが無くなってスッキリしそうです。
ChangeNotifierを使う
ChangeNotifier
を使うと、Stateを保持し、Stateの更新を購読者に通知することができます。
class CounterNotifier extends ChangeNotifier {
int _count = 0;
int get count => _count;
void increment() {
_count++;
notifyListeners();
}
}
final counterNotifier = CounterNotifier();
使う側はListenableBuilder
のパラメータにcounterNotifier
を渡し、
Stateが通知された時に呼ばれるbuilder
関数でウィジェットを返します。
class MyCounter extends StatelessWidget {
const MyCounter({super.key});
Widget build(BuildContext context) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
ListenableBuilder(
listenable: counterNotifier,
builder: (context, child) {
return Text('counter: ${counterNotifier.count}');
}),
TextButton(
child: const Text('Increment'),
onPressed: () {
counterNotifier.increment();
},
),
],
));
}
}
ValueNotifierを使う
ValueNotifier
はChangeNotifier
をシンプルにしたものです。
その名の通り、1つの値を保持し、更新を通知できます。
使う側はChangeNotifier
の時と同様ListenableBuilder
も使うことができますが、
ValueListenableBuilder
を使うとbuilder
関数の引数で更新された値を直接受け取ることができます。
final ValueNotifier<int> counterNotifier = ValueNotifier(0);
class MyCounter extends StatelessWidget {
const MyCounter({super.key});
Widget build(BuildContext context) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
ValueListenableBuilder(
valueListenable: counterNotifier,
builder: (context, value, child) {
return Text('counter: $value');
}),
TextButton(
child: const Text('Increment'),
onPressed: () {
counterNotifier.value++;
},
),
],
));
}
}
アプリケーションアーキテクチャとしてMVVMを使う
これまでに、Stateの更新やウィジェットへの通知の仕組みを見てきました。
これらの仕組みをMVVMに適用する例が載っていたのでご紹介します。
Model
まずはModel部分の定義です。
ModelはHTTP通信などのローレベルの処理を担当します。
Flutterに依存しないような作りにすることでモックへの差し替えがしやすく、テストしやすい作りになっています。
import 'package:http/http.dart';
class CounterData {
CounterData(this.count);
final int count;
}
class CounterModel {
Future<CounterData> loadCountFromServer() async {
final uri = Uri.parse('https://myfluttercounterapp.net/count');
final response = await get(uri);
if (response.statusCode != 200) {
throw ('Failed to update resource');
}
return CounterData(int.parse(response.body));
}
Future<CounterData> updateCountOnServer(int newCount) async {
// ...
}
}
ViewModel
ChangeNotifier
を継承したクラスをViewModelとして定義します。
Stateを内部に持ち、notifyListeners
で通知します。
Viewからのイベントは increment
メソッドを通じて実行します。
ガイドではViewModelはレストランのウェイターのようなものだと解説されています。
Modelがキッチン、Viewが顧客で、キッチンと顧客の間を仲介するのがViewModelです。
確かにレストランで顧客がキッチンと直接やりとりすることはないですよね。
import 'package:flutter/foundation.dart';
class CounterViewModel extends ChangeNotifier {
final CounterModel model;
int? count;
String? errorMessage;
CounterViewModel(this.model);
Future<void> init() async {
try {
count = (await model.loadCountFromServer()).count;
} catch (e) {
errorMessage = 'Could not initialize counter';
}
notifyListeners();
}
Future<void> increment() async {
var count = this.count;
if (count == null) {
throw('Not initialized');
}
try {
await model.updateCountOnServer(count + 1);
count++;
} catch(e) {
errorMessage = 'Count not update count';
}
notifyListeners();
}
}
View
最後にViewです。
ViewModelはChangeNotifier
なのでStateが更新された時にUIの更新も行うことができます。
Viewはレストランで言う顧客ですね。顧客がすることはなんでしょうか?
ウェイターに注文して(ViewModelへの依頼)、提供された料理(State)を食べます(消費します)(=Stateを使ってUIを構築します)
ListenableBuilder(
listenable: viewModel,
builder: (context, child) {
return Column(
children: [
if (viewModel.errorMessage != null)
Text(
'Error: ${viewModel.errorMessage}',
style: Theme.of(context)
.textTheme
.labelSmall
?.apply(color: Colors.red),
),
Text('Count: ${viewModel.count}'),
TextButton(
onPressed: () {
viewModel.increment();
},
child: Text('Increment'),
),
],
);
},
)
MVVMを適用することでViewはUIの構築だけを考えればよくなりました。
ViewModelはModelとViewの仲介役として機能し、ViewのためのStateを管理したり、Viewからイベントを受けとり、Modelを使ってデータの取得などを行います。
Modelは例ではHTTP通信などのローレベルな処理だけでしたが、アプリによってはビジネスロジックを担当する部分となります。
おわりに
First week experience of Flutter の State management を読んで、Flutterが標準で提供している状態管理の仕組みを理解しました。
実際のアプリケーション開発の現場ではRiverpodなどの状態管理パッケージを使うことが多いと思いますが、Flutterが標準で提供している状態管理の仕組みの理解が深まりました。